--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 92dba250847e86bffe20cb16d085cde063495a5f
Parents : d2456c8
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T04:11:28-05:00
feat(Favourites Layout): implement API endpoints for getting and setting favourites layout, including validation and normalization logic. Add frontend integration for layout persistence and retrieval, ensuring robust handling of layout data across sessions.
Changes
9 files changed, 1173 insertions(+), 96 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 40a40610..cbc54245 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -10501,6 +10501,49 @@ class ReticulumMeshChat:
status=500,
)
+ @routes.get("/api/v1/favourites/layout")
+ async def favourites_layout_get(request):
+ layout = self.database.announces.get_favourites_layout()
+ return web.json_response({"layout": layout})
+
+ @routes.put("/api/v1/favourites/layout")
+ async def favourites_layout_put(request):
+ from meshchatx.src.backend.favourites_layout import layout_payload_too_large
+
+ content_length = request.content_length
+ if content_length is not None and layout_payload_too_large(
+ content_length,
+ ):
+ return web.json_response(
+ {"message": "favourites layout exceeds size limit"},
+ status=413,
+ )
+ try:
+ raw = await request.read()
+ except Exception:
+ return web.json_response(
+ {"message": "Invalid request body"},
+ status=400,
+ )
+ if layout_payload_too_large(len(raw)):
+ return web.json_response(
+ {"message": "favourites layout exceeds size limit"},
+ status=413,
+ )
+ try:
+ data = json.loads(raw.decode("utf-8"))
+ except Exception:
+ return web.json_response(
+ {"message": "Invalid JSON body"},
+ status=400,
+ )
+ layout = data.get("layout") if isinstance(data, dict) else None
+ try:
+ saved = self.database.announces.set_favourites_layout(layout)
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
+ return web.json_response({"layout": saved})
+
# serve archived pages
@routes.get("/api/v1/nomadnet/archives")
async def get_all_archived_pages(request):
diff --git a/meshchatx/src/backend/database/announces.py b/meshchatx/src/backend/database/announces.py
index 066b25ac..69fd9795 100644
--- a/meshchatx/src/backend/database/announces.py
+++ b/meshchatx/src/backend/database/announces.py
@@ -1,7 +1,13 @@
# SPDX-License-Identifier: 0BSD
+import json
from datetime import UTC, datetime
+from meshchatx.src.backend.favourites_layout import (
+ NOMADNET_FAVOURITES_LAYOUT_KEY,
+ normalize_favourites_layout,
+)
+
from .provider import DatabaseProvider
@@ -243,3 +249,47 @@ class AnnounceDAO:
)
else:
self.provider.execute("DELETE FROM favourite_destinations")
+
+ def get_favourites_layout(self):
+ row = self.provider.fetchone(
+ "SELECT value FROM config WHERE key = ?",
+ (NOMADNET_FAVOURITES_LAYOUT_KEY,),
+ )
+ if not row or row["value"] in (None, ""):
+ return None
+ try:
+ parsed = json.loads(row["value"])
+ except (TypeError, ValueError, json.JSONDecodeError):
+ return None
+ return normalize_favourites_layout(parsed)
+
+ def set_favourites_layout(self, layout):
+ from meshchatx.src.backend.favourites_layout import (
+ MAX_LAYOUT_JSON_BYTES,
+ )
+
+ normalized = normalize_favourites_layout(layout)
+ if normalized is None:
+ msg = "invalid favourites layout"
+ raise ValueError(msg)
+ payload = json.dumps(normalized, separators=(",", ":"), ensure_ascii=False)
+ if len(payload.encode("utf-8")) > MAX_LAYOUT_JSON_BYTES:
+ msg = "favourites layout exceeds size limit"
+ raise ValueError(msg)
+ now = datetime.now(UTC)
+ self.provider.execute(
+ """
+ INSERT INTO config (key, value, created_at, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(key) DO UPDATE SET
+ value = EXCLUDED.value,
+ updated_at = EXCLUDED.updated_at
+ """,
+ (
+ NOMADNET_FAVOURITES_LAYOUT_KEY,
+ payload,
+ now,
+ now,
+ ),
+ )
+ return normalized
diff --git a/meshchatx/src/backend/favourites_layout.py b/meshchatx/src/backend/favourites_layout.py
new file mode 100644
index 00000000..a67a642d
--- /dev/null
+++ b/meshchatx/src/backend/favourites_layout.py
@@ -0,0 +1,125 @@
+"""Normalize and validate NomadNet favourite section layout blobs."""
+
+NOMADNET_FAVOURITES_LAYOUT_KEY = "nomadnet_favourites_layout"
+
+# Hard caps keep PUT payloads cheap to parse/store and avoid pathological layouts.
+MAX_SECTIONS = 64
+MAX_SECTION_ID_LEN = 64
+MAX_SECTION_NAME_LEN = 128
+MAX_HASHES_PER_SECTION = 2000
+MAX_TOTAL_HASHES = 4000
+MAX_HASH_LEN = 64
+MAX_LAYOUT_JSON_BYTES = 256 * 1024
+
+_FORBIDDEN_SECTION_IDS = frozenset({"__proto__", "constructor", "prototype"})
+
+
+def _clip_str(value, max_len):
+ if not isinstance(value, str):
+ return ""
+ if len(value) <= max_len:
+ return value
+ return value[:max_len]
+
+
+def normalize_favourites_layout(layout):
+ """Return a sanitized layout dict, or ``None`` when the shape is invalid."""
+ if not isinstance(layout, dict) or not isinstance(layout.get("sections"), list):
+ return None
+
+ raw_by_section = layout.get("favouritesBySection")
+ favourites_by_section = raw_by_section if isinstance(raw_by_section, dict) else {}
+
+ sections = []
+ section_ids = set()
+ for section in layout.get("sections") or []:
+ if len(sections) >= MAX_SECTIONS:
+ break
+ if not isinstance(section, dict):
+ continue
+ section_id = section.get("id")
+ if not isinstance(section_id, str):
+ continue
+ section_id = section_id.strip()
+ if (
+ not section_id
+ or len(section_id) > MAX_SECTION_ID_LEN
+ or section_id in section_ids
+ or section_id in _FORBIDDEN_SECTION_IDS
+ ):
+ continue
+ section_ids.add(section_id)
+ name = _clip_str(section.get("name"), MAX_SECTION_NAME_LEN)
+ sections.append(
+ {
+ "id": section_id,
+ "name": name,
+ "collapsed": section.get("collapsed") is True,
+ }
+ )
+
+ if not sections:
+ return None
+
+ raw_order = layout.get("sectionOrder")
+ if isinstance(raw_order, list):
+ section_order = []
+ for sid in raw_order:
+ if not isinstance(sid, str):
+ continue
+ sid = sid.strip()
+ if sid in section_ids and sid not in section_order:
+ section_order.append(sid)
+ if len(section_order) >= MAX_SECTIONS:
+ break
+ else:
+ section_order = [section["id"] for section in sections]
+ for section in sections:
+ if section["id"] not in section_order:
+ section_order.append(section["id"])
+
+ sanitized_map = {}
+ total_hashes = 0
+ for key, value in favourites_by_section.items():
+ if not isinstance(key, str):
+ continue
+ key = key.strip()
+ if key not in section_ids or key in _FORBIDDEN_SECTION_IDS:
+ continue
+ if not isinstance(value, list):
+ continue
+ hashes = []
+ seen = set()
+ for item in value:
+ if (
+ total_hashes >= MAX_TOTAL_HASHES
+ or len(hashes) >= MAX_HASHES_PER_SECTION
+ ):
+ break
+ if not isinstance(item, str):
+ continue
+ h = item.strip()
+ if not h or len(h) > MAX_HASH_LEN or h in seen:
+ continue
+ seen.add(h)
+ hashes.append(h)
+ total_hashes += 1
+ sanitized_map[key] = hashes
+
+ for section in sections:
+ sanitized_map.setdefault(section["id"], [])
+
+ return {
+ "sections": sections,
+ "sectionOrder": section_order,
+ "favouritesBySection": sanitized_map,
+ }
+
+
+def layout_payload_too_large(raw_body_len):
+ """Return True when a raw request body exceeds the layout size budget."""
+ try:
+ size = int(raw_body_len)
+ except (TypeError, ValueError):
+ return False
+ return size > MAX_LAYOUT_JSON_BYTES
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
index 27c9f127..c26dc12e 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
@@ -716,6 +716,11 @@ import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import { isUnknownNodeDisplayName } from "../../js/nomadUnknownNodeName.js";
+import {
+ loadNomadFavouritesLayout,
+ readLocalNomadFavouritesLayout,
+ saveNomadFavouritesLayout,
+} from "../../js/nomadFavouritesLayoutStore.js";
export default {
name: "NomadNetworkSidebar",
@@ -792,6 +797,7 @@ export default {
sections: [],
sectionOrder: [],
favouritesBySection: {},
+ favouriteLayoutLoadGen: 0,
draggingFavouriteHash: null,
draggingFavouriteHashes: [],
draggingFavouriteSectionId: null,
@@ -942,8 +948,11 @@ export default {
},
},
mounted() {
- this.loadFavouriteLayout();
+ this._layoutPersistTimer = null;
+ // Paint immediately from local cache/defaults, then hydrate from the identity DB.
+ this.applyFavouriteLayout(readLocalNomadFavouritesLayout());
this.ensureFavouriteLayout();
+ this.reloadFavouriteLayoutFromStore();
this._smUpMql = window.matchMedia("(min-width: 640px)");
this._smUpResize = () => {
this.smUp = this._smUpMql.matches;
@@ -951,11 +960,16 @@ export default {
this._smUpResize();
this._smUpMql.addEventListener("change", this._smUpResize);
this._onNomadnetFavouritesLayoutImported = () => {
- this.loadFavouriteLayout();
+ this.reloadFavouriteLayoutFromStore();
};
GlobalEmitter.on("nomadnet-favourites-layout-imported", this._onNomadnetFavouritesLayoutImported);
},
unmounted() {
+ if (this._layoutPersistTimer) {
+ clearTimeout(this._layoutPersistTimer);
+ this._layoutPersistTimer = null;
+ this.persistFavouriteLayout({ immediate: true });
+ }
if (this._smUpMql && this._smUpResize) {
this._smUpMql.removeEventListener("change", this._smUpResize);
}
@@ -964,6 +978,31 @@ export default {
}
},
methods: {
+ applyFavouriteLayout(layout) {
+ if (!layout) {
+ if (this.sections.length === 0) {
+ this.resetDefaultSections();
+ }
+ return;
+ }
+ this.sections = layout.sections || [];
+ this.sectionOrder =
+ layout.sectionOrder ||
+ (layout.sections ? layout.sections.map((section) => section.id) : this.sectionOrder);
+ this.favouritesBySection = layout.favouritesBySection || {};
+ if (this.sections.length === 0) {
+ this.resetDefaultSections();
+ }
+ },
+ async reloadFavouriteLayoutFromStore() {
+ const gen = ++this.favouriteLayoutLoadGen;
+ const layout = await loadNomadFavouritesLayout(window.api);
+ if (gen !== this.favouriteLayoutLoadGen) {
+ return;
+ }
+ this.applyFavouriteLayout(layout);
+ this.ensureFavouriteLayout();
+ },
toggleFavouritesSelectionMode() {
this.favouritesSelectionMode = !this.favouritesSelectionMode;
if (!this.favouritesSelectionMode) {
@@ -1152,45 +1191,37 @@ export default {
this.favouritesBySection = { [defaultSection.id]: [] };
},
loadFavouriteLayout() {
- try {
- const stored = localStorage.getItem("meshchat.nomadnet.favourites.layout");
- if (stored) {
- const parsed = JSON.parse(stored);
- this.sections = parsed.sections || [];
- this.sectionOrder =
- parsed.sectionOrder ||
- (parsed.sections ? parsed.sections.map((section) => section.id) : this.sectionOrder);
- this.favouritesBySection = parsed.favouritesBySection || {};
- return;
- }
- const legacyOrder = localStorage.getItem("meshchat.nomadnet.favourites");
- if (legacyOrder) {
- const parsedOrder = JSON.parse(legacyOrder);
- const defaultSection = this.buildDefaultSection();
- this.sections = [defaultSection];
- this.sectionOrder = [defaultSection.id];
- this.favouritesBySection = { [defaultSection.id]: parsedOrder };
+ void this.reloadFavouriteLayoutFromStore();
+ },
+ persistFavouriteLayout(options = {}) {
+ // User-driven saves invalidate in-flight remote loads so they cannot clobber edits.
+ // Reconciliation persists (fromEnsure) must not, or the first hydrate is discarded.
+ if (!options.fromEnsure) {
+ this.favouriteLayoutLoadGen += 1;
+ }
+ const layout = {
+ sections: this.sections,
+ sectionOrder: this.sectionOrder,
+ favouritesBySection: this.favouritesBySection,
+ };
+ const flush = () => {
+ this._layoutPersistTimer = null;
+ return saveNomadFavouritesLayout(window.api, layout);
+ };
+ if (options.immediate) {
+ if (this._layoutPersistTimer) {
+ clearTimeout(this._layoutPersistTimer);
+ this._layoutPersistTimer = null;
}
- } catch (e) {
- console.log(e);
- }
- if (this.sections.length === 0) {
- this.resetDefaultSections();
+ return flush();
}
- },
- persistFavouriteLayout() {
- try {
- localStorage.setItem(
- "meshchat.nomadnet.favourites.layout",
- JSON.stringify({
- sections: this.sections,
- sectionOrder: this.sectionOrder,
- favouritesBySection: this.favouritesBySection,
- })
- );
- } catch (e) {
- console.log(e);
+ if (this._layoutPersistTimer) {
+ clearTimeout(this._layoutPersistTimer);
}
+ this._layoutPersistTimer = setTimeout(() => {
+ void flush();
+ }, 250);
+ return undefined;
},
ensureFavouriteLayout() {
if (!Array.isArray(this.favourites) || this.favourites.length === 0) {
@@ -1246,7 +1277,7 @@ export default {
this.sectionOrder = nextSectionOrder;
this.favouritesBySection = nextFavouritesBySection;
if (sectionsChanged || orderChanged || favouritesChanged) {
- this.persistFavouriteLayout();
+ this.persistFavouriteLayout({ fromEnsure: true });
}
},
isBlocked(identityHash) {
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 1e8b49dc..d513f976 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -2928,6 +2928,11 @@ import { isMicronWasmBundled } from "../../js/MicronWasmLoader.js";
import MicronWasmUpdateModal from "./MicronWasmUpdateModal.vue";
import NotificationSoundSettings from "./NotificationSoundSettings.vue";
import PluginsSettingsSection from "./PluginsSettingsSection.vue";
+import {
+ loadNomadFavouritesLayout,
+ normalizeNomadFavouritesLayout,
+ saveNomadFavouritesLayout,
+} from "../../js/nomadFavouritesLayoutStore.js";
export default {
name: "SettingsPage",
@@ -4518,31 +4523,7 @@ export default {
event.target.value = "";
},
normalizeNomadnetFavouritesLayoutShape(layout) {
- if (!layout || typeof layout !== "object" || !Array.isArray(layout.sections)) {
- return null;
- }
- const favouritesBySection =
- layout.favouritesBySection && typeof layout.favouritesBySection === "object"
- ? layout.favouritesBySection
- : {};
- const sectionOrder = Array.isArray(layout.sectionOrder)
- ? layout.sectionOrder
- : layout.sections.map((s) => s && s.id).filter(Boolean);
- const sections = layout.sections
- .filter((s) => s && typeof s.id === "string")
- .map((s) => ({
- id: s.id,
- name: typeof s.name === "string" ? s.name : "",
- collapsed: s.collapsed === true,
- }));
- const sanitizedMap = {};
- for (const k of Object.keys(favouritesBySection)) {
- const arr = favouritesBySection[k];
- if (Array.isArray(arr)) {
- sanitizedMap[k] = arr.filter((h) => typeof h === "string");
- }
- }
- return { sections, sectionOrder, favouritesBySection: sanitizedMap };
+ return normalizeNomadFavouritesLayout(layout);
},
parseNomadnetFavouritesImportData(data) {
if (!data || typeof data !== "object") {
@@ -4562,29 +4543,13 @@ export default {
const layout = this.normalizeNomadnetFavouritesLayoutShape(data);
return layout ? { kind: "full", layout } : null;
},
- mergeNomadnetFavouritesSectionImport(payload) {
+ async mergeNomadnetFavouritesSectionImport(payload) {
const sec = payload.section;
const hashes = Array.isArray(payload.destination_hashes)
? payload.destination_hashes.filter((h) => typeof h === "string")
: [];
- let raw = null;
- try {
- raw = localStorage.getItem("meshchat.nomadnet.favourites.layout");
- } catch {
- raw = null;
- }
- let base = { sections: [], sectionOrder: [], favouritesBySection: {} };
- if (raw) {
- try {
- const parsed = JSON.parse(raw);
- const normalized = this.normalizeNomadnetFavouritesLayoutShape(parsed);
- if (normalized) {
- base = normalized;
- }
- } catch {
- // keep default base
- }
- }
+ const loaded = await loadNomadFavouritesLayout(window.api);
+ let base = loaded || { sections: [], sectionOrder: [], favouritesBySection: {} };
const sections = [...base.sections];
const sectionOrder = [...base.sectionOrder];
const favouritesBySection = { ...base.favouritesBySection };
@@ -4612,18 +4577,14 @@ export default {
if (!merged) {
throw new Error("invalid layout");
}
- localStorage.setItem("meshchat.nomadnet.favourites.layout", JSON.stringify(merged));
+ await saveNomadFavouritesLayout(window.api, merged);
},
async exportNomadnetFavouritesLayout() {
let layout = { sections: [], sectionOrder: [], favouritesBySection: {} };
try {
- const raw = localStorage.getItem("meshchat.nomadnet.favourites.layout");
- if (raw) {
- const parsed = JSON.parse(raw);
- const normalized = this.normalizeNomadnetFavouritesLayoutShape(parsed);
- if (normalized) {
- layout = normalized;
- }
+ const loaded = await loadNomadFavouritesLayout(window.api);
+ if (loaded) {
+ layout = loaded;
}
} catch {
// keep empty layout
@@ -4674,9 +4635,9 @@ export default {
});
}
if (parsed.kind === "full") {
- localStorage.setItem("meshchat.nomadnet.favourites.layout", JSON.stringify(parsed.layout));
+ await saveNomadFavouritesLayout(window.api, parsed.layout);
} else if (parsed.kind === "section") {
- this.mergeNomadnetFavouritesSectionImport(parsed.payload);
+ await this.mergeNomadnetFavouritesSectionImport(parsed.payload);
} else {
throw new Error("invalid file");
}
diff --git a/meshchatx/src/frontend/js/nomadFavouritesLayoutStore.js b/meshchatx/src/frontend/js/nomadFavouritesLayoutStore.js
new file mode 100644
index 00000000..673d4034
--- /dev/null
+++ b/meshchatx/src/frontend/js/nomadFavouritesLayoutStore.js
@@ -0,0 +1,291 @@
+// SPDX-License-Identifier: 0BSD
+
+export const NOMAD_FAVOURITES_LAYOUT_KEY = "meshchat.nomadnet.favourites.layout";
+export const NOMAD_FAVOURITES_LEGACY_ORDER_KEY = "meshchat.nomadnet.favourites";
+
+// Keep in sync with meshchatx/src/backend/favourites_layout.py
+export const MAX_SECTIONS = 64;
+export const MAX_SECTION_ID_LEN = 64;
+export const MAX_SECTION_NAME_LEN = 128;
+export const MAX_HASHES_PER_SECTION = 2000;
+export const MAX_TOTAL_HASHES = 4000;
+export const MAX_HASH_LEN = 64;
+
+const FORBIDDEN_SECTION_IDS = new Set(["__proto__", "constructor", "prototype"]);
+
+/**
+ * @param {unknown} value
+ * @param {number} maxLen
+ * @returns {string}
+ */
+function clipStr(value, maxLen) {
+ if (typeof value !== "string") {
+ return "";
+ }
+ return value.length <= maxLen ? value : value.slice(0, maxLen);
+}
+
+/**
+ * @param {unknown} layout
+ * @returns {{sections: object[], sectionOrder: string[], favouritesBySection: Record<string, string[]>}|null}
+ */
+export function normalizeNomadFavouritesLayout(layout) {
+ if (!layout || typeof layout !== "object" || Array.isArray(layout) || !Array.isArray(layout.sections)) {
+ return null;
+ }
+ const favouritesBySection =
+ layout.favouritesBySection &&
+ typeof layout.favouritesBySection === "object" &&
+ !Array.isArray(layout.favouritesBySection)
+ ? layout.favouritesBySection
+ : {};
+ const sections = [];
+ const sectionIds = new Set();
+ for (const section of layout.sections) {
+ if (sections.length >= MAX_SECTIONS) {
+ break;
+ }
+ if (!section || typeof section !== "object" || Array.isArray(section)) {
+ continue;
+ }
+ if (typeof section.id !== "string") {
+ continue;
+ }
+ const sectionId = section.id.trim();
+ if (
+ !sectionId ||
+ sectionId.length > MAX_SECTION_ID_LEN ||
+ sectionIds.has(sectionId) ||
+ FORBIDDEN_SECTION_IDS.has(sectionId)
+ ) {
+ continue;
+ }
+ sectionIds.add(sectionId);
+ sections.push({
+ id: sectionId,
+ name: clipStr(section.name, MAX_SECTION_NAME_LEN),
+ collapsed: section.collapsed === true,
+ });
+ }
+ if (sections.length === 0) {
+ return null;
+ }
+ const sectionOrder = [];
+ if (Array.isArray(layout.sectionOrder)) {
+ for (const sid of layout.sectionOrder) {
+ if (typeof sid !== "string") {
+ continue;
+ }
+ const id = sid.trim();
+ if (sectionIds.has(id) && !sectionOrder.includes(id)) {
+ sectionOrder.push(id);
+ }
+ if (sectionOrder.length >= MAX_SECTIONS) {
+ break;
+ }
+ }
+ }
+ for (const section of sections) {
+ if (!sectionOrder.includes(section.id)) {
+ sectionOrder.push(section.id);
+ }
+ }
+ const sanitizedMap = Object.create(null);
+ let totalHashes = 0;
+ for (const key of Object.keys(favouritesBySection)) {
+ if (!sectionIds.has(key) || FORBIDDEN_SECTION_IDS.has(key)) {
+ continue;
+ }
+ const arr = favouritesBySection[key];
+ if (!Array.isArray(arr)) {
+ continue;
+ }
+ const hashes = [];
+ const seen = new Set();
+ for (const item of arr) {
+ if (totalHashes >= MAX_TOTAL_HASHES || hashes.length >= MAX_HASHES_PER_SECTION) {
+ break;
+ }
+ if (typeof item !== "string") {
+ continue;
+ }
+ const h = item.trim();
+ if (!h || h.length > MAX_HASH_LEN || seen.has(h)) {
+ continue;
+ }
+ seen.add(h);
+ hashes.push(h);
+ totalHashes += 1;
+ }
+ sanitizedMap[key] = hashes;
+ }
+ for (const section of sections) {
+ if (!Object.prototype.hasOwnProperty.call(sanitizedMap, section.id)) {
+ sanitizedMap[section.id] = [];
+ }
+ }
+ return { sections, sectionOrder, favouritesBySection: sanitizedMap };
+}
+
+/**
+ * Stable JSON for equality checks (avoids unnecessary PUTs).
+ * @param {object|null} layout
+ * @returns {string}
+ */
+export function serializeNomadFavouritesLayout(layout) {
+ const normalized = normalizeNomadFavouritesLayout(layout);
+ if (!normalized) {
+ return "";
+ }
+ return JSON.stringify(normalized);
+}
+
+export function readLocalNomadFavouritesLayout() {
+ try {
+ if (typeof window === "undefined" || !window.localStorage) {
+ return null;
+ }
+ const stored = window.localStorage.getItem(NOMAD_FAVOURITES_LAYOUT_KEY);
+ if (stored) {
+ return normalizeNomadFavouritesLayout(JSON.parse(stored));
+ }
+ const legacyOrder = window.localStorage.getItem(NOMAD_FAVOURITES_LEGACY_ORDER_KEY);
+ if (legacyOrder) {
+ const parsedOrder = JSON.parse(legacyOrder);
+ if (Array.isArray(parsedOrder)) {
+ return normalizeNomadFavouritesLayout({
+ sections: [{ id: "default", name: "Favourites", collapsed: false }],
+ sectionOrder: ["default"],
+ favouritesBySection: { default: parsedOrder.filter((h) => typeof h === "string") },
+ });
+ }
+ }
+ } catch {
+ // ignore
+ }
+ return null;
+}
+
+function writeLocalLayout(layout) {
+ try {
+ if (typeof window === "undefined" || !window.localStorage) {
+ return;
+ }
+ const normalized = normalizeNomadFavouritesLayout(layout);
+ if (!normalized) {
+ return;
+ }
+ window.localStorage.setItem(NOMAD_FAVOURITES_LAYOUT_KEY, JSON.stringify(normalized));
+ } catch {
+ // ignore
+ }
+}
+
+let saveInFlight = null;
+let pendingSaveLayout = null;
+let lastSavedSerialized = "";
+
+/**
+ * Load favourite section layout from the identity DB, migrating localStorage once.
+ * @param {*} api window.api-like client
+ * @returns {Promise<object|null>}
+ */
+export async function loadNomadFavouritesLayout(api) {
+ if (!api?.get) {
+ return readLocalNomadFavouritesLayout();
+ }
+ try {
+ const response = await api.get("/api/v1/favourites/layout");
+ const remote = normalizeNomadFavouritesLayout(response?.data?.layout);
+ if (remote) {
+ writeLocalLayout(remote);
+ lastSavedSerialized = serializeNomadFavouritesLayout(remote);
+ return remote;
+ }
+ } catch {
+ // fall through to local
+ }
+ const local = readLocalNomadFavouritesLayout();
+ if (local && api?.put) {
+ try {
+ const response = await api.put("/api/v1/favourites/layout", { layout: local });
+ const saved = normalizeNomadFavouritesLayout(response?.data?.layout) || local;
+ writeLocalLayout(saved);
+ lastSavedSerialized = serializeNomadFavouritesLayout(saved);
+ try {
+ window.localStorage?.removeItem(NOMAD_FAVOURITES_LEGACY_ORDER_KEY);
+ } catch {
+ // ignore
+ }
+ return saved;
+ } catch {
+ return local;
+ }
+ }
+ if (local) {
+ lastSavedSerialized = serializeNomadFavouritesLayout(local);
+ }
+ return local;
+}
+
+async function flushPendingSave(api) {
+ while (pendingSaveLayout) {
+ const layout = pendingSaveLayout;
+ pendingSaveLayout = null;
+ const serialized = serializeNomadFavouritesLayout(layout);
+ if (!serialized || serialized === lastSavedSerialized) {
+ continue;
+ }
+ try {
+ const response = await api.put("/api/v1/favourites/layout", { layout });
+ // A newer save may have arrived while this PUT was in flight; prefer that.
+ if (pendingSaveLayout) {
+ continue;
+ }
+ const saved = normalizeNomadFavouritesLayout(response?.data?.layout) || layout;
+ writeLocalLayout(saved);
+ lastSavedSerialized = serializeNomadFavouritesLayout(saved);
+ } catch {
+ writeLocalLayout(layout);
+ // Keep lastSavedSerialized unchanged so a later retry can push again.
+ }
+ }
+}
+
+/**
+ * Persist favourite section layout to the identity DB (and local cache).
+ * Coalesces concurrent saves and skips no-op PUTs.
+ * @param {*} api window.api-like client
+ * @param {object} layout
+ * @returns {Promise<object|null>}
+ */
+export async function saveNomadFavouritesLayout(api, layout) {
+ const normalized = normalizeNomadFavouritesLayout(layout);
+ if (!normalized) {
+ return null;
+ }
+ writeLocalLayout(normalized);
+ if (!api?.put) {
+ lastSavedSerialized = serializeNomadFavouritesLayout(normalized);
+ return normalized;
+ }
+ const serialized = serializeNomadFavouritesLayout(normalized);
+ if (serialized && serialized === lastSavedSerialized) {
+ return normalized;
+ }
+ pendingSaveLayout = normalized;
+ if (!saveInFlight) {
+ saveInFlight = flushPendingSave(api).finally(() => {
+ saveInFlight = null;
+ });
+ }
+ await saveInFlight;
+ return readLocalNomadFavouritesLayout() || normalized;
+}
+
+/** Test helper: reset coalescing state between cases. */
+export function _resetNomadFavouritesLayoutSaveStateForTests() {
+ saveInFlight = null;
+ pendingSaveLayout = null;
+ lastSavedSerialized = "";
+}
diff --git a/tests/backend/test_favourites_layout.py b/tests/backend/test_favourites_layout.py
new file mode 100644
index 00000000..8de35bc5
--- /dev/null
+++ b/tests/backend/test_favourites_layout.py
@@ -0,0 +1,300 @@
+# SPDX-License-Identifier: 0BSD
+
+import json
+import shutil
+import tempfile
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+import RNS
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
+
+from meshchatx.meshchat import ReticulumMeshChat
+from meshchatx.src.backend.favourites_layout import (
+ MAX_HASH_LEN,
+ MAX_HASHES_PER_SECTION,
+ MAX_LAYOUT_JSON_BYTES,
+ MAX_SECTION_ID_LEN,
+ MAX_SECTION_NAME_LEN,
+ MAX_SECTIONS,
+ MAX_TOTAL_HASHES,
+ layout_payload_too_large,
+ normalize_favourites_layout,
+)
+
+
+def test_normalize_favourites_layout_rejects_invalid():
+ assert normalize_favourites_layout(None) is None
+ assert normalize_favourites_layout({}) is None
+ assert normalize_favourites_layout({"sections": []}) is None
+ assert normalize_favourites_layout([]) is None
+ assert normalize_favourites_layout("nope") is None
+
+
+def test_normalize_favourites_layout_sanitizes():
+ layout = normalize_favourites_layout(
+ {
+ "sections": [
+ {"id": "default", "name": "Favourites", "collapsed": False},
+ {"id": "custom", "name": "Custom", "collapsed": True},
+ {"id": "default", "name": "dup"},
+ ],
+ "sectionOrder": ["custom", "missing"],
+ "favouritesBySection": {
+ "custom": ["abc", 12],
+ "orphan": ["x"],
+ },
+ }
+ )
+ assert layout is not None
+ assert [s["id"] for s in layout["sections"]] == ["default", "custom"]
+ assert layout["sectionOrder"] == ["custom", "default"]
+ assert layout["favouritesBySection"]["custom"] == ["abc"]
+ assert layout["favouritesBySection"]["default"] == []
+ assert "orphan" not in layout["favouritesBySection"]
+
+
+def test_normalize_rejects_prototype_pollution_keys():
+ layout = normalize_favourites_layout(
+ {
+ "sections": [
+ {"id": "__proto__", "name": "bad"},
+ {"id": "constructor", "name": "bad"},
+ {"id": "ok", "name": "Good"},
+ ],
+ "sectionOrder": ["__proto__", "ok"],
+ "favouritesBySection": {
+ "__proto__": ["a" * 32],
+ "ok": ["b" * 32],
+ },
+ }
+ )
+ assert layout is not None
+ assert [s["id"] for s in layout["sections"]] == ["ok"]
+ assert "__proto__" not in layout["favouritesBySection"]
+ assert layout["favouritesBySection"]["ok"] == ["b" * 32]
+
+
+def test_normalize_enforces_caps():
+ sections = [
+ {"id": f"s{i}", "name": "x" * (MAX_SECTION_NAME_LEN + 20)}
+ for i in range(MAX_SECTIONS + 10)
+ ]
+ hashes = [f"{i:032x}" for i in range(MAX_HASHES_PER_SECTION + 50)]
+ layout = normalize_favourites_layout(
+ {
+ "sections": sections,
+ "sectionOrder": [s["id"] for s in sections],
+ "favouritesBySection": {sections[0]["id"]: hashes},
+ }
+ )
+ assert layout is not None
+ assert len(layout["sections"]) == MAX_SECTIONS
+ assert len(layout["sections"][0]["name"]) == MAX_SECTION_NAME_LEN
+ assert (
+ len(layout["favouritesBySection"][sections[0]["id"]]) == MAX_HASHES_PER_SECTION
+ )
+
+
+def test_normalize_enforces_total_hash_cap():
+ sections = [{"id": f"s{i}", "name": f"S{i}"} for i in range(4)]
+ per = (MAX_TOTAL_HASHES // 4) + 10
+ layout = normalize_favourites_layout(
+ {
+ "sections": sections,
+ "sectionOrder": [s["id"] for s in sections],
+ "favouritesBySection": {
+ s["id"]: [f"{s['id']}{i:028x}"[:32] for i in range(per)]
+ for s in sections
+ },
+ }
+ )
+ assert layout is not None
+ total = sum(len(v) for v in layout["favouritesBySection"].values())
+ assert total <= MAX_TOTAL_HASHES
+
+
+def test_normalize_dedupes_hashes_and_trims():
+ layout = normalize_favourites_layout(
+ {
+ "sections": [{"id": " default ", "name": " Name "}],
+ "sectionOrder": [" default "],
+ "favouritesBySection": {
+ "default": [" abc ", "abc", "x" * (MAX_HASH_LEN + 5), ""],
+ },
+ }
+ )
+ assert layout["sections"][0]["id"] == "default"
+ assert layout["sections"][0]["name"] == " Name "[:MAX_SECTION_NAME_LEN]
+ assert layout["favouritesBySection"]["default"] == ["abc"]
+
+
+def test_layout_payload_too_large():
+ assert layout_payload_too_large(MAX_LAYOUT_JSON_BYTES + 1) is True
+ assert layout_payload_too_large(MAX_LAYOUT_JSON_BYTES) is False
+ assert layout_payload_too_large("nope") is False
+
+
+@given(
+ payload=st.one_of(
+ st.none(),
+ st.booleans(),
+ st.integers(),
+ st.text(),
+ st.binary(),
+ st.lists(st.integers()),
+ )
+)
+@settings(max_examples=80, suppress_health_check=[HealthCheck.too_slow])
+def test_normalize_never_throws_on_garbage(payload):
+ assert normalize_favourites_layout(payload) is None or isinstance(
+ normalize_favourites_layout(payload),
+ dict,
+ )
+
+
+@given(
+ section_ids=st.lists(
+ st.text(min_size=1, max_size=MAX_SECTION_ID_LEN + 8),
+ min_size=0,
+ max_size=MAX_SECTIONS + 5,
+ ),
+ names=st.lists(
+ st.text(max_size=MAX_SECTION_NAME_LEN + 20), max_size=MAX_SECTIONS + 5
+ ),
+ hashes=st.lists(st.text(max_size=MAX_HASH_LEN + 8), max_size=40),
+)
+@settings(
+ max_examples=60,
+ suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
+)
+def test_normalize_fuzz_structured(section_ids, names, hashes):
+ sections = []
+ for i, sid in enumerate(section_ids):
+ sections.append(
+ {
+ "id": sid,
+ "name": names[i] if i < len(names) else 123,
+ "collapsed": i % 2 == 0,
+ }
+ )
+ raw = {
+ "sections": sections,
+ "sectionOrder": section_ids[::-1] + ["missing"],
+ "favouritesBySection": {sid: hashes for sid in section_ids[:3]},
+ }
+ out = normalize_favourites_layout(raw)
+ if out is None:
+ return
+ assert len(out["sections"]) <= MAX_SECTIONS
+ assert len(out["sectionOrder"]) == len(out["sections"])
+ assert set(out["sectionOrder"]) == {s["id"] for s in out["sections"]}
+ total = 0
+ for sid, values in out["favouritesBySection"].items():
+ assert sid in {s["id"] for s in out["sections"]}
+ assert len(values) <= MAX_HASHES_PER_SECTION
+ assert len(values) == len(set(values))
+ total += len(values)
+ assert total <= MAX_TOTAL_HASHES
+
+
+@pytest.fixture
+def temp_dir():
+ dir_path = tempfile.mkdtemp()
+ yield dir_path
+ shutil.rmtree(dir_path)
+
+
+@pytest.fixture
+def mock_rns_minimal():
+ with (
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
+ ):
+ mock_rns_instance = mock_rns.return_value
+ mock_rns_instance.configpath = "/tmp/mock_config"
+ mock_rns_instance.is_connected_to_shared_instance = False
+ mock_rns_instance.transport_enabled.return_value = True
+
+ mock_id = MagicMock(spec=RNS.Identity)
+ mock_id.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id.hexhash = mock_id.hash.hex()
+ mock_id.get_private_key.return_value = b"test_private_key"
+ yield mock_id
+
+
+@pytest.mark.asyncio
+async def test_favourites_layout_get_put(mock_rns_minimal, temp_dir):
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ get_handler = None
+ put_handler = None
+ for route in app.get_routes():
+ if route.path == "/api/v1/favourites/layout" and route.method == "GET":
+ get_handler = route.handler
+ if route.path == "/api/v1/favourites/layout" and route.method == "PUT":
+ put_handler = route.handler
+ assert get_handler is not None
+ assert put_handler is not None
+
+ empty = await get_handler(MagicMock())
+ assert json.loads(empty.body)["layout"] is None
+
+ layout = {
+ "sections": [
+ {"id": "default", "name": "Favourites", "collapsed": False},
+ {"id": "custom", "name": "Custom", "collapsed": False},
+ ],
+ "sectionOrder": ["default", "custom"],
+ "favouritesBySection": {
+ "default": [],
+ "custom": ["a" * 32],
+ },
+ }
+ body = json.dumps({"layout": layout}).encode("utf-8")
+ request = MagicMock()
+ request.content_length = len(body)
+ request.read = AsyncMock(return_value=body)
+ put_response = await put_handler(request)
+ put_data = json.loads(put_response.body)
+ assert put_data["layout"]["favouritesBySection"]["custom"] == ["a" * 32]
+
+ get_response = await get_handler(MagicMock())
+ get_data = json.loads(get_response.body)
+ assert get_data["layout"]["sectionOrder"] == ["default", "custom"]
+
+ bad_body = json.dumps({"layout": {"sections": []}}).encode("utf-8")
+ bad = MagicMock()
+ bad.content_length = len(bad_body)
+ bad.read = AsyncMock(return_value=bad_body)
+ bad_response = await put_handler(bad)
+ assert bad_response.status == 400
+
+
+@pytest.mark.asyncio
+async def test_favourites_layout_put_rejects_oversized_body(mock_rns_minimal, temp_dir):
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ put_handler = None
+ for route in app.get_routes():
+ if route.path == "/api/v1/favourites/layout" and route.method == "PUT":
+ put_handler = route.handler
+ break
+ assert put_handler is not None
+
+ request = MagicMock()
+ request.content_length = MAX_LAYOUT_JSON_BYTES + 1
+ request.read = AsyncMock(return_value=b"{}")
+ response = await put_handler(request)
+ assert response.status == 413
diff --git a/tests/frontend/NomadNetworkSidebar.test.js b/tests/frontend/NomadNetworkSidebar.test.js
index 6d7c3a12..ca43c549 100644
--- a/tests/frontend/NomadNetworkSidebar.test.js
+++ b/tests/frontend/NomadNetworkSidebar.test.js
@@ -4,6 +4,7 @@ import NomadNetworkSidebar from "@/components/nomadnetwork/NomadNetworkSidebar.v
import DialogUtils from "@/js/DialogUtils";
import GlobalState from "@/js/GlobalState";
import GlobalEmitter from "@/js/GlobalEmitter";
+import { _resetNomadFavouritesLayoutSaveStateForTests } from "@/js/nomadFavouritesLayoutStore.js";
vi.mock("@/js/DialogUtils", () => ({
default: {
@@ -28,8 +29,10 @@ describe("NomadNetworkSidebar.vue", () => {
};
beforeEach(() => {
+ _resetNomadFavouritesLayoutSaveStateForTests();
axiosMock = {
- get: vi.fn().mockResolvedValue({ data: {} }),
+ get: vi.fn().mockResolvedValue({ data: { layout: null } }),
+ put: vi.fn().mockImplementation((_url, body) => Promise.resolve({ data: body || {} })),
post: vi.fn().mockResolvedValue({ data: {} }),
delete: vi.fn().mockResolvedValue({ data: {} }),
};
@@ -48,6 +51,7 @@ describe("NomadNetworkSidebar.vue", () => {
afterEach(() => {
delete window.api;
vi.unstubAllGlobals();
+ _resetNomadFavouritesLayoutSaveStateForTests();
});
const mountSidebar = (overrides = {}) =>
@@ -335,12 +339,11 @@ describe("NomadNetworkSidebar.vue", () => {
}
return null;
});
+ axiosMock.get.mockResolvedValue({ data: { layout } });
const wrapper = mountSidebar({ favourites: [] });
await wrapper.vm.$nextTick();
-
expect(wrapper.vm.favouritesBySection.custom).toEqual([favHash]);
- expect(localStorage.setItem).not.toHaveBeenCalled();
await wrapper.setProps({ favourites: [defaultFavourite] });
await wrapper.vm.$nextTick();
@@ -348,4 +351,44 @@ describe("NomadNetworkSidebar.vue", () => {
expect(wrapper.vm.favouritesBySection.custom).toContain(favHash);
expect(wrapper.vm.favouritesBySection.default || []).not.toContain(favHash);
});
+
+ it("persists favourite layout through the favourites layout API", async () => {
+ let resolveGet;
+ axiosMock.get.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveGet = resolve;
+ })
+ );
+ const wrapper = mountSidebar();
+ await vi.waitFor(() => expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/favourites/layout"));
+ await vi.waitFor(() => expect(wrapper.vm.sections.length).toBeGreaterThan(0));
+ axiosMock.put.mockClear();
+ wrapper.vm.sections = [
+ { id: "default", name: "Favourites", collapsed: false },
+ { id: "custom", name: "Custom", collapsed: false },
+ ];
+ wrapper.vm.sectionOrder = ["default", "custom"];
+ wrapper.vm.favouritesBySection = {
+ default: [],
+ custom: [defaultFavourite.destination_hash],
+ };
+ await wrapper.vm.persistFavouriteLayout({ immediate: true });
+ // Late hydrate must not wipe the edit we just persisted.
+ resolveGet({ data: { layout: null } });
+ await Promise.resolve();
+ expect(wrapper.vm.favouritesBySection.custom).toEqual([defaultFavourite.destination_hash]);
+ await vi.waitFor(() =>
+ expect(axiosMock.put).toHaveBeenCalledWith(
+ "/api/v1/favourites/layout",
+ expect.objectContaining({
+ layout: expect.objectContaining({
+ favouritesBySection: expect.objectContaining({
+ custom: [defaultFavourite.destination_hash],
+ }),
+ }),
+ })
+ )
+ );
+ });
});
diff --git a/tests/frontend/nomadFavouritesLayoutStore.test.js b/tests/frontend/nomadFavouritesLayoutStore.test.js
new file mode 100644
index 00000000..50b62aaf
--- /dev/null
+++ b/tests/frontend/nomadFavouritesLayoutStore.test.js
@@ -0,0 +1,233 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ MAX_HASHES_PER_SECTION,
+ MAX_SECTION_NAME_LEN,
+ MAX_SECTIONS,
+ MAX_TOTAL_HASHES,
+ _resetNomadFavouritesLayoutSaveStateForTests,
+ loadNomadFavouritesLayout,
+ normalizeNomadFavouritesLayout,
+ saveNomadFavouritesLayout,
+ serializeNomadFavouritesLayout,
+} from "@/js/nomadFavouritesLayoutStore.js";
+
+describe("nomadFavouritesLayoutStore", () => {
+ beforeEach(() => {
+ _resetNomadFavouritesLayoutSaveStateForTests();
+ vi.stubGlobal("localStorage", {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ _resetNomadFavouritesLayoutSaveStateForTests();
+ });
+
+ it("normalizes layout shape", () => {
+ const normalized = normalizeNomadFavouritesLayout({
+ sections: [
+ { id: "default", name: "Favourites", collapsed: false },
+ { id: "custom", name: "Custom", collapsed: true },
+ { id: "default", name: "dup" },
+ ],
+ sectionOrder: ["custom", "missing"],
+ favouritesBySection: {
+ custom: ["abc", 1],
+ orphan: ["x"],
+ },
+ });
+ expect(normalized.sections).toHaveLength(2);
+ expect(normalized.sectionOrder).toEqual(["custom", "default"]);
+ expect(normalized.favouritesBySection.custom).toEqual(["abc"]);
+ expect(normalized.favouritesBySection.default).toEqual([]);
+ expect(normalized.favouritesBySection.orphan).toBeUndefined();
+ });
+
+ it("rejects arrays and prototype pollution keys", () => {
+ expect(normalizeNomadFavouritesLayout([])).toBeNull();
+ const normalized = normalizeNomadFavouritesLayout({
+ sections: [
+ { id: "__proto__", name: "bad" },
+ { id: "constructor", name: "bad" },
+ { id: "ok", name: "Good" },
+ ],
+ favouritesBySection: {
+ __proto__: ["a"],
+ ok: ["b"],
+ },
+ });
+ expect(normalized.sections.map((s) => s.id)).toEqual(["ok"]);
+ expect(normalized.favouritesBySection.ok).toEqual(["b"]);
+ expect(Object.prototype.hasOwnProperty.call(normalized.favouritesBySection, "__proto__")).toBe(false);
+ });
+
+ it("enforces size caps", () => {
+ const sections = Array.from({ length: MAX_SECTIONS + 8 }, (_, i) => ({
+ id: `s${i}`,
+ name: "n".repeat(MAX_SECTION_NAME_LEN + 20),
+ }));
+ const hashes = Array.from({ length: MAX_HASHES_PER_SECTION + 20 }, (_, i) => i.toString(16).padStart(32, "0"));
+ const normalized = normalizeNomadFavouritesLayout({
+ sections,
+ sectionOrder: sections.map((s) => s.id),
+ favouritesBySection: { s0: hashes },
+ });
+ expect(normalized.sections).toHaveLength(MAX_SECTIONS);
+ expect(normalized.sections[0].name).toHaveLength(MAX_SECTION_NAME_LEN);
+ expect(normalized.favouritesBySection.s0).toHaveLength(MAX_HASHES_PER_SECTION);
+ });
+
+ it("enforces total hash cap across sections", () => {
+ const sections = [
+ { id: "a", name: "A" },
+ { id: "b", name: "B" },
+ { id: "c", name: "C" },
+ { id: "d", name: "D" },
+ ];
+ const per = Math.floor(MAX_TOTAL_HASHES / 4) + 20;
+ const normalized = normalizeNomadFavouritesLayout({
+ sections,
+ favouritesBySection: Object.fromEntries(
+ sections.map((s) => [
+ s.id,
+ Array.from({ length: per }, (_, i) => `${s.id}${i}`.padEnd(32, "0").slice(0, 32)),
+ ])
+ ),
+ });
+ const total = Object.values(normalized.favouritesBySection).reduce((n, arr) => n + arr.length, 0);
+ expect(total).toBeLessThanOrEqual(MAX_TOTAL_HASHES);
+ });
+
+ it("loads remote layout and caches locally", async () => {
+ const layout = {
+ sections: [{ id: "default", name: "Favourites", collapsed: false }],
+ sectionOrder: ["default"],
+ favouritesBySection: { default: ["a"] },
+ };
+ const api = {
+ get: vi.fn().mockResolvedValue({ data: { layout } }),
+ put: vi.fn(),
+ };
+ const loaded = await loadNomadFavouritesLayout(api);
+ expect(loaded.favouritesBySection.default).toEqual(["a"]);
+ expect(localStorage.setItem).toHaveBeenCalled();
+ expect(api.put).not.toHaveBeenCalled();
+ });
+
+ it("migrates localStorage layout to the API when remote is empty", async () => {
+ const layout = {
+ sections: [{ id: "default", name: "Favourites", collapsed: false }],
+ sectionOrder: ["default"],
+ favouritesBySection: { default: ["migrated"] },
+ };
+ localStorage.getItem.mockImplementation((key) => {
+ if (key === "meshchat.nomadnet.favourites.layout") {
+ return JSON.stringify(layout);
+ }
+ return null;
+ });
+ const api = {
+ get: vi.fn().mockResolvedValue({ data: { layout: null } }),
+ put: vi.fn().mockResolvedValue({ data: { layout } }),
+ };
+ const loaded = await loadNomadFavouritesLayout(api);
+ expect(loaded.favouritesBySection.default).toEqual(["migrated"]);
+ expect(api.put).toHaveBeenCalledWith("/api/v1/favourites/layout", { layout });
+ });
+
+ it("skips no-op saves after an identical layout was persisted", async () => {
+ const layout = {
+ sections: [{ id: "default", name: "Favourites", collapsed: false }],
+ sectionOrder: ["default"],
+ favouritesBySection: { default: [] },
+ };
+ const api = {
+ put: vi.fn().mockResolvedValue({ data: { layout } }),
+ };
+ await saveNomadFavouritesLayout(api, layout);
+ await saveNomadFavouritesLayout(api, layout);
+ expect(api.put).toHaveBeenCalledTimes(1);
+ expect(serializeNomadFavouritesLayout(layout)).toContain("default");
+ });
+
+ it("coalesces concurrent saves into one PUT of the latest layout", async () => {
+ const resolvers = [];
+ const api = {
+ put: vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolvers.push(resolve);
+ })
+ ),
+ };
+ const first = {
+ sections: [{ id: "default", name: "Favourites", collapsed: false }],
+ sectionOrder: ["default"],
+ favouritesBySection: { default: ["one"] },
+ };
+ const second = {
+ sections: [{ id: "default", name: "Favourites", collapsed: false }],
+ sectionOrder: ["default"],
+ favouritesBySection: { default: ["two"] },
+ };
+ const p1 = saveNomadFavouritesLayout(api, first);
+ const p2 = saveNomadFavouritesLayout(api, second);
+ await Promise.resolve();
+ expect(api.put).toHaveBeenCalledTimes(1);
+ expect(api.put.mock.calls[0][1].layout.favouritesBySection.default).toEqual(["one"]);
+ resolvers[0]({ data: { layout: first } });
+ await vi.waitFor(() => expect(api.put).toHaveBeenCalledTimes(2));
+ expect(api.put.mock.calls[1][1].layout.favouritesBySection.default).toEqual(["two"]);
+ resolvers[1]({ data: { layout: second } });
+ await Promise.all([p1, p2]);
+ expect(api.put).toHaveBeenCalledTimes(2);
+ });
+
+ it("fuzzing: normalize never throws on random payloads", () => {
+ const samples = [
+ null,
+ undefined,
+ 0,
+ 1,
+ true,
+ false,
+ "",
+ "layout",
+ [],
+ {},
+ { sections: null },
+ { sections: "x" },
+ { sections: [{ id: 1 }] },
+ { sections: [{ id: "a", name: { nested: true } }], favouritesBySection: [] },
+ { sections: [{ id: "a" }], favouritesBySection: { a: "not-array" } },
+ { sections: [{ id: "a" }], favouritesBySection: { a: [null, {}, [], "ok"] } },
+ ];
+ for (let i = 0; i < 200; i++) {
+ samples.push({
+ sections: Array.from({ length: (i % 10) + 1 }, (_, j) => ({
+ id: i % 7 === 0 ? `__proto__` : `id-${i}-${j}`,
+ name: String.fromCharCode(0x20 + ((i + j) % 90)).repeat((i % 40) + 1),
+ collapsed: i % 2 === 0,
+ })),
+ sectionOrder: [`id-${i}-0`, "missing", null, 12],
+ favouritesBySection: {
+ [`id-${i}-0`]: Array.from({ length: (i % 15) + 1 }, (_, k) =>
+ k % 5 === 0 ? k : `h${i}${k}`.padEnd(32, "0").slice(0, 32)
+ ),
+ },
+ });
+ }
+ for (const sample of samples) {
+ expect(() => normalizeNomadFavouritesLayout(sample)).not.toThrow();
+ const out = normalizeNomadFavouritesLayout(sample);
+ if (out) {
+ expect(out.sections.length).toBeGreaterThan(0);
+ expect(out.sections.length).toBeLessThanOrEqual(MAX_SECTIONS);
+ expect(out.sectionOrder.length).toBe(out.sections.length);
+ }
+ }
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────